Skip to content

Zoom and fit-to-view in the node editor - #359

Merged
matt-edmondson merged 3 commits into
mainfrom
claude/node-editor-zoom
Sep 8, 2026
Merged

Zoom and fit-to-view in the node editor#359
matt-edmondson merged 3 commits into
mainfrom
claude/node-editor-zoom

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

ImNodes has no zoom, so a consumer who wants one has to build it out of what NodeEditorRenderer does — and the renderer is the only thing that knows how node positions get into ImNodes and back out. ktsu.Coder built one in its own view layer (ktsu-dev/Coder#19), and awkwardly: it scaled the engine's positions before the frame and unscaled them after, because from outside the renderer there is nowhere else to put the transform. This puts it where it belongs, so every consumer gets it and nobody has to reach into the engine to do it.

NodeEditorRenderer.Zoom

Positions are scaled on the way into ImNodes and unscaled on the way back out, about the middle of the editor area, so the engine never holds a zoomed value. That is the point rather than an implementation detail: the engine's positions and sizes are what the force-directed layout runs on, and its rest length, repulsion distance and overlap margin are all lengths that would mean something different in a space that changed whenever the user zoomed.

The font is scaled to match, and so are the node editor's own lengths — padding, corner rounding, border and pin sizes, grid spacing — because scaling positions alone would only pack the nodes closer while they stayed the same size, and scaling the font alone would leave the same chrome around smaller text. All of them together are what makes a node zoomed out a smaller node.

Sizes measured while zoomed

A size measured while the view is zoomed is not the node's size, however it is scaled back: the font size rounds to whole pixels, and ImGui's own spacing inside the node does not scale at all. Measured at 0.5× and divided by 0.5, a 92px node comes back 120px — a value that depends on how far out the user happened to be. Handing that to a layout that keeps node boxes apart would re-space the graph every time the view changed.

So a node that has already been measured keeps its size, and only a node that has never been measured takes a zoomed measurement — an approximate size beats none for a node created while zoomed out — corrected the next time the view is at its own scale.

The trap worth naming, since Coder fell into it: unscaling every measurement unconditionally. Sizes only arrive from ImNodes when they change, so the same value gets unscaled again on every frame it doesn't. At 0.5× the nodes double every frame; a graph left alone for a second is thousands of times its real size, which in practice is enough geometry to trip ImGui's assertion on 16-bit vertex indices.

FitToView(engine, editorSize)

Centres the graph and picks the largest zoom it still fits at, with a margin so nothing sits against an edge, and never magnifies — a graph that already fits is shown at its own size, since magnifying it is not what "fit" means to someone who asked to see all of it.

It moves the nodes rather than panning the editor. It has to: Render writes every node's position into ImNodes on the frame it draws it, so a pan is undone as soon as it is read back. The whole arrangement is translated by one offset, so a layout's shape is preserved rather than disturbed by the act of looking at it.

A node's size is only known once it has been drawn, so a graph fitted before its first frame is fitted against sizes that are still zero; the XML docs say so, and a caller that fits on opening should fit again once dimensions arrive.

Testing

ImGui.NodeEditor.Tests — 71 passing, of which the 8 new ZoomTests are this PR's. They drive real frames through the harness, which is the only place this can be judged: zoom is made of what the renderer writes into ImNodes and reads back, and neither is visible without drawing.

Covered: the engine's positions are untouched by zooming, its dimensions are untouched (asserted exactly, not within a tolerance — that is the compounding bug's test), zooming out to 0.25 and up to 2 and back to 1 returns the graph exactly where it was, the zoom clamps to its range, fitting centres and zooms out until an oversized graph fits, fitting does not magnify one that already fits, fitting an empty graph reports it, and a zoomed graph draws with no ImGui usage errors.

Whole solution builds green.

Merged with main

main renamed ImGuiNodeEditor to ImGui.NodeEditor, library and tests, and the namespaces with them. The renderer's changes carried over to the renamed path on their own; ZoomTests moved with the directory and took the new namespace; CLAUDE.md's node editor entry keeps the new name with the zoom note appended.

For consumers

Zoom defaults to 1 and the transform is skipped entirely at that value, so nothing changes for a consumer that does not set it. Coder can drop its own copy once this ships.

Not scaled: ImGui's global style (ItemSpacing and friends) inside nodes, which is why measured sizes are approximate while zoomed rather than exact. Scaling that would mean saving and restoring the whole ImGui style per frame, which costs more than the accuracy is worth given that measurements taken while zoomed are already not used for already-measured nodes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QwzCFb8zhd263sZeRbF7ba

ImNodes has no zoom, so consumers that want one have to build it out of
what the renderer does — and the renderer is the only thing that knows how
node positions get into ImNodes and back. ktsu.Coder built one in its own
view layer, awkwardly: it scaled the engine's positions before the frame
and unscaled them after, because from outside there is nowhere else to put
the transform.

NodeEditorRenderer.Zoom does it where it belongs. Positions are scaled on
the way into ImNodes and unscaled on the way back out, so the engine never
holds a zoomed value: its positions and sizes are what the force-directed
layout runs on, and rest length, repulsion distance and overlap margin
would all mean something different in a space that changed whenever the
user zoomed. The font and the node editor's own lengths — padding, corner
rounding, pin sizes, grid spacing — are scaled with it, so a node zoomed
out is a smaller node rather than the same chrome around smaller text.

A size measured while zoomed is not the node's size however it is scaled
back: the font size rounds to whole pixels and ImGui's spacing inside the
node does not scale. A node already measured therefore keeps its size, and
only one never measured takes a zoomed measurement, corrected the next time
the view is at its own scale. Unscaling every measurement instead is the
trap here: sizes only arrive when they change, so the same value would be
unscaled again every frame and a graph left alone for a second would have
nodes thousands of times their real size.

FitToView centres the graph and picks the largest zoom it still fits at,
with a margin, never magnifying — a graph that already fits is shown at its
own size. It moves the nodes rather than panning, because the renderer
writes their positions in every frame and a pan is undone as soon as it is
read back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwzCFb8zhd263sZeRbF7ba
KTSU0003: this repository checks arguments with Ensure.NotNull rather than
ArgumentNullException.ThrowIfNull, for framework compatibility.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwzCFb8zhd263sZeRbF7ba
main renamed ImGuiNodeEditor to ImGui.NodeEditor, in both the library and
its tests, and the namespaces with it. The renderer's changes carried over
to the renamed path on their own; ZoomTests moved with the directory and
took the new namespace, and CLAUDE.md's node editor entry keeps the new
name with the zoom note on the end of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QwzCFb8zhd263sZeRbF7ba
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants